Skip to content

Single-pass expression analysis groundwork - answer type questions from ExpressionResults - #5857

Open
ondrejmirtes wants to merge 47 commits into
2.2.xfrom
resolve-type-rewrite-2
Open

Single-pass expression analysis groundwork - answer type questions from ExpressionResults#5857
ondrejmirtes wants to merge 47 commits into
2.2.xfrom
resolve-type-rewrite-2

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Jun 12, 2026

Copy link
Copy Markdown
Member

Groundwork for the "new world" where an expression is traversed once: after processExpr, its ExpressionResult knows the before/after scopes, the type (typeCallback) and the narrowing (specifyTypesCallback), composed from child results instead of re-walking subtrees. Handlers then stop implementing TypeResolvingExprHandler; the old entry points (MutatingScope::resolveType, the TypeSpecifier dispatcher) are guarded behind NewWorld::disableOldWorld() and get mass-deleted in PHPStan 3.0.

What's on the branch, bottom up:

  • Guards + ExpressionResultFactory: old-world type resolution entry points throw when NewWorld::disableOldWorld() is flipped (the migration meter); all ExpressionResult construction goes through a generated factory.
  • ExpressionResult carries beforeScope, expr, typeCallback, specifyTypesCallback and is stored per node in ExpressionResultStorage (layered O(1) duplicate()), replacing the stored before-Scope.
  • ExprHandler / TypeResolvingExprHandler split: resolveType/specifyTypes move to the sub-interface so handlers can shed them one by one.
  • ExpressionResultStorageStack: old-world consumers (TypeSpecifier dispatcher, extensions, rules below PHP 8.1, unconverted handlers' resolveType) keep working for converted handlers' nodes. Every scope shares the stack created by its internal scope factory; NodeScopeResolver pushes the storage of the analysis in progress through MutatingScope::pushExpressionResultStorage() (always popped in finally, throwing on imbalance), and MutatingScope answers from the stored result - or processes a synthetic node on demand. Scopes never reference a storage directly, so nothing pins the result graph with the cycle collector disabled in bin/phpstan. Also adds MutatingScope::applySpecifiedTypes - filterBySpecifiedTypes without Scope::getType().
  • First two migrations: ScalarHandler and ArrayHandler no longer implement TypeResolvingExprHandler. The array migration is a precision win the old world cannot reach: each item type is captured at its own evaluation point, so [$b = 1, $b + 1, $c = $b, $c + 2, $c++, $c] infers array{1, 2, 1, 3, 1, 2}.

Verified: full test suite green, make phpstan clean, and analysis memory back at baseline (no leak from the result graph despite gc_disable()).

Closes phpstan/phpstan#13944
Closes phpstan/phpstan#12207
Closes phpstan/phpstan#7155
Closes phpstan/phpstan#14396
Closes phpstan/phpstan#11953
Closes phpstan/phpstan#12780

🤖 Generated with Claude Code

Closes phpstan/phpstan#14999
Closes phpstan/phpstan#13334

Closes phpstan/phpstan#15004

return $this->withFlavor(false);
}

private function withFlavor(bool $fiber): self

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this read withFiber?

@ondrejmirtes
ondrejmirtes force-pushed the resolve-type-rewrite-2 branch 2 times, most recently from eb31077 to 59cbf22 Compare June 19, 2026 11:44
@ondrejmirtes
ondrejmirtes force-pushed the resolve-type-rewrite-2 branch from 59cbf22 to 125cf22 Compare June 20, 2026 11:56
@ondrejmirtes
ondrejmirtes force-pushed the resolve-type-rewrite-2 branch 4 times, most recently from f98892f to 4455baa Compare July 6, 2026 22:20
@ondrejmirtes
ondrejmirtes force-pushed the resolve-type-rewrite-2 branch from 61fe06e to e38aadd Compare July 16, 2026 14:56
ondrejmirtes referenced this pull request Jul 23, 2026
Every property fetch / method call resolves its type by walking down to
the chain root to detect a nullsafe operator (NullsafeShortCircuitingHelper),
costing O(N²) walk steps per chain of depth N — with or without an actual
nullsafe operator in the chain. Deep loop-wrapped plain chains make that
walk dominate: 3.71s -> 3.14s wall (-15%), -18% user CPU from the
recursion-to-loop rewrite. The real-world counterpart is Symfony
TreeBuilder fluent chains (300+ calls in one statement) in Sylius bundle
Configuration classes, which dropped up to 23% per file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016szvNF5RXhACdfMQNc6DVL
@ondrejmirtes
ondrejmirtes force-pushed the resolve-type-rewrite-2 branch 12 times, most recently from fb22d34 to 84b1614 Compare July 28, 2026 17:31
@ondrejmirtes
ondrejmirtes force-pushed the resolve-type-rewrite-2 branch from 98028f1 to 613afb6 Compare August 14, 2026 18:41
Rules and DependencyResolver receive a node's callback and immediately ask
about the node or its subexpressions. Under fibers a pre-order callback
parks on its first ask and resumes when the natural walk stores the result
anyway - but a synchronously invoked callback (the plain resolver on
PHP < 8.1) re-walked everything it asked about through the on-demand
bridge: ~380k re-walks during self-analysis, +15% user CPU vs fibers.

Expression nodes now emit their callback right after the handler's result
is stored, and the expression-carrying statements (echo, return,
expression statements) after their expressions are processed - in both
cases with the scope captured at the entry position, so rules observe the
same (scope, answer) pair as before. Self-analysis on the plain resolver
drops from 470k to 107k on-demand walks; fibers are unchanged.
…lts are stored

Continues the previous commit for the remaining synchronous-callback
re-walk clusters: if/elseif/switch emit their statement callback right
after the condition's result is stored (rules like the constant-condition
and boolean-in-condition helpers ask about the condition), and
prepareTarget() emits the raw assignment target's callback after the walk
composed and stored the target's read result (DependencyResolver and the
property rules ask about the target and its receiver). Scopes stay
captured at the entry position. Self-analysis on the plain resolver drops
from 107k to 79k on-demand walks - 14.6k of them on real nodes, down from
380k before the two commits.
…h callback

The constant-condition rules listening on BooleanAndNode/BooleanOrNode ask
about the raw binary expression, and foreach rules about the iteratee.
The boolean handlers now store their result before emitting the virtual
node (the later store in processExprNodeInternal is an idempotent re-store
of the same result), and the foreach statement emits its callback after
the iteratee's result is stored, with the entry scope.
Two diffuse per-event costs measured against the plain resolver: the
store hook called processPendingFibersForRequestedExpr() for millions of
stores although almost none have a pending fiber - an inline empty check
skips the call and the object-id lookup; and gatherers received a
FiberScope although they are engine code that never asks about types -
they get the raw scope now, and the scopes they capture answer later asks
through the storage hub like any MutatingScope.
…ensions

Extends the argument priming to the two remaining lazily-invoked extension
surfaces: the dynamic static-method return type extensions dispatched for
constructors in NewHandler's exactInstantiation() (runs in the
typeCallback), and the function/method/static-method type-specifying
extensions (run at narrowing-apply time in the specifyTypesCallback). Both
can ask Scope::getType() about the call's arguments after the walk's
storage frame is no longer current; the primed storage answers those asks
from the argument results instead of re-walking on demand.

The eager surfaces (throw-type and parameter-out extensions) run during
the handler with the walk storage current and need no priming.
OutputBufferHelper priced the incremented ob_get_level() type by walking a
synthetic Plus of two TypeExprs through Scope::getType() - a core-engine
synthetic re-walk. It is now a service that calls
InitializerExprTypeResolver::getPlusType() on the operand types directly.
Two more core synthetic re-walks replaced by the logic they were fishing
for: StaticCallHandler priced `new $classExpr` through Scope::getType()
to learn what a class-string receiver instantiates - that is
getObjectTypeOrClassStringObjectType() on the receiver's own result; and
FuncCallHandler's clone-with support walked a synthetic Clone_ although
the object argument was just processed - CloneHandler's type logic is now
an extracted resolveCloneType() both call sites share.
The static-call promoted-properties check priced $this through a synthetic
Variable walk - it is a plain scope-state read. The parent-instantiation
synthetic New_ walk in exactInstantiation() stays: it re-resolves the
parent constructor's template types from the arguments, which a direct
recursion cannot - now documented at the site.
…ider

Resolving an unqualified name probes the namespaced variant first, and a
miss surfaces as a constructed-and-thrown IdentifierNotFound inside the
reflector - repeated for every re-ask of the same name. The single-pass
engine's per-flavour callbacks re-ask the same names many times per file
(2,500 exception throws while analysing ConstantArrayTypeTest alone).
The resolution is now memoized per (namespace, name as written); the key
keeps the asked case because the resolved name preserves it for the
incorrect-case rules.
The processArgs() restructure lost two things the pre-ArgsResult shape
had: the resolved acceptor was selected (and generic-resolved) for every
call although a single template-free acceptor IS the resolved acceptor -
the fast path the original selectFromArgs() took - and the
per-argument type-driven predicate re-traversed the acceptor's parameter
types on every argument instead of once per call. Restoring both cuts
GenericParametersAcceptorResolver::resolve from 5,175 to 648 calls while
analysing ConstantArrayTypeTest.
A rule asking the type of a virtual node itself (BooleanOrNode, ...)
parks its fiber - the node is never stored - and the flush walks it on
demand, hitting processExprNodeInternal()'s unhandled-expr throw and
aborting the whole file's analysis with an internal error.
MutatingScope::resolveType() already answers such nodes with mixed;
processExprOnDemand() now takes the same fallback, keeping the main
walk's throw for real source nodes.
… state

A rule callback may derive the scope it was handed - e.g. assignExpression()
pinning a call-site literal onto a parameter variable, the way callback-
analysing tooling re-analyses a callee body via the public processNodes()
API with more specific argument types. FiberScope's settled-result fast path
and post-suspend read returned the naked walk-position type, ignoring such
derivations. Both now consume through askScopeVariableStateMatches() in a
rule-facing mode: variables unknown to the asking scope and variables
narrower at the evaluation position (the coalesce right side priced on the
left's falsey branch) leave the walk answer standing; an asker-side
refinement re-prices on the asking scope's state.

MutatingScope::toFiberScope() seeds the created scope with its origin (a
WeakReference - a strong back-reference would cycle with the $fiberScope
cache and never free with GC disabled), so toMutatingScope() answers with
the walk scope itself and the guard's beforeScope identity check hits for
same-position asks.
…ad path

Old-world resolveType() ran the extension hook on every ask, both flavours.
The single-pass engine consults it in ExpressionResult::getType() but not in
getTypeOnScope() - the read an assignment fills the target's holder from -
nor in getNativeType(), and eager types short-circuited before the hook. An
extension's override (phpstan-doctrine's ReturnQueryBuilderExpressionType-
ResolverExtension rewriting a method-returned QueryBuilder into its branch
type) never entered the scope state, so every downstream chain read saw the
raw declared type. All three read paths now consult the extensions first,
positioned at the read's scope.
hasOffsetValue(n, T) on a list proves indices 0..n exist, so popping the
highest index keeps hasOffset(0..n-1) - previously the accessory answered
with no opinion and the whole intersection degraded to a possibly-empty
list, so a second array_pop() in the same statement typed as nullable.
Shifting reindexes, so the value known at n moves to n - 1 intact.
TemplateType members pass through unchanged, as in
intersectTypesPreserveTemplateType().
Consulting the extensions on every read re-ran phpstan-doctrine's
ReturnQueryBuilderExpressionTypeResolverExtension - which resolves the
receiver's method reflection - on each read of each call-typed result,
costing several percent of user CPU on projects registering such an
extension. One full-null round settles the decline for the result; a
non-null answer stays live and is re-derived at the read's scope.
Post-order emission stores the node's own result and every subnode result
before the callback fires, so FiberScope answers every ask from the
storage: settled results through the asking-scope guard, filter-derived
and promoted asks by re-reading on the result's before-scope, unstored
asks (synthetic nodes, nodes ahead of the walk) on demand through the
MutatingScope path. Node callbacks run directly - no fiber creation,
parking, or flushing. The dead fiber machinery is removed separately.
A rule may pass the scope it was handed - the rule-facing FiberScope - as
the initial scope of a processNodes()/processStmtNodes() walk (shipmonk's
ForbidCheckedExceptionInCallableRule re-walks callable bodies this way).
The walk then anchors its results to fiber scopes, and consuming such a
result from a filter-derived ask re-enters the rule-facing ask paths,
deriving scopes without end. The public entry points now normalize to the
state-identical MutatingScope, and preprocessScope() guards the
consumption side the same way.
Nothing suspends since node-callback asks are answered synchronously:
the fiber pool (parked/pending fiber arrays on the storage and their
native mirror), the park/resume driver, the end-of-scope flush with its
on-demand memo and state snapshots, and the processing-expression
tracking that existed to delay flushes all go. processStmtNodesInternal
folds into its only remaining variant.
FiberNodeScopeResolver's only remaining behavior - unwrap gatherers,
skip noop callbacks, hand rules the FiberScope - moves into the base
resolver, and the class goes together with the FnsrExtension autowiring
switch, the PHPSTAN_FNSR toggle, and the PHP 8.1 gate: nothing here
needs fibers anymore, so 7.4 and 8.0 get the same storage-backed rule
scope with walk-position answers as everything else. Test expectations
that keyed on the resolver split become unconditional.
Nothing about the class is fiber-specific anymore: it is the scope every
node callback receives, answering asks from the walk's stored expression
results. The Fiber namespace dissolves (the resolver override moved to
the base class earlier), toFiberScope() becomes toNodeCallbackScope(),
the scope factory flavour flag says what it creates, and the fnsr.php
type-inference fixture moves under nsrt/ auto-discovery, making its
dedicated test class redundant.
The walk scope is what the conversion produces: the engine-facing
MutatingScope behind a NodeCallbackScope. The scope factory counterpart
becomes toWalkScopeFactory(), the callback scope's cached conversion and
seed follow (walkScope, seededWalkScope, seedWalkScope()), and the
ScopeOps clone mirror resolves the renamed properties - including the
previously missed seeded reference, which a native clone must reset like
every other per-instance memo. The Fiber class-not-found ignore for
sub-8.1 self-analysis is obsolete now that nothing references Fiber.
…e boundary only

toMutatingScope() returns $this and stays as a deprecated alias -
extensions (phpstan-doctrine's OtherMethodQueryBuilderParser) call it.

resetPerFileAnalysisState() moves from processNodes() to the per-file
callers (FileAnalyser, TypeInferenceTestCase): extensions start nested
processNodes() walks mid-file - phpstan-doctrine parsing a query-builder
method, rule tooling re-analysing a callee - and each wipe forced the
outer file to rebuild its per-file caches, re-converging closure types
and recomputing narrowing memos. On shipmonk's test files, where warm
reflection state makes those rebuilds expensive and query-builder
consults are frequent, whole analysis-order windows ran 2x slower than
2.2.x while the same files in isolation were near parity.
Rules and collectors re-ask the same nodes across a callback batch, and
the walk scope's resolvedTypes memo used to answer those repeats in O(1)
before the callback-facing scope existed. Every repeat paid the
stored-result guard - variable-state compares, node-key printing on
re-priced asks - which shipmonk's rule set (disallowed-calls formatting
every call, the dead-code collectors) multiplied into whole test-file
windows running twice as slow as 2.2.x. The entry pins the asked node:
a dropped synthetic's object id can be reused by the next synthetic, and
the identity check rejects the stale hit.
@ondrejmirtes
ondrejmirtes force-pushed the resolve-type-rewrite-2 branch from 0d9fa98 to 12e97e9 Compare August 17, 2026 19:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment